import { Download, ExternalLink } from 'lucide-react'; import type { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { t, tOpt } from '@/i18n'; import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; import { apiAnalytics } from '@/lib/api-analytics'; import { apiExplore } from '@/lib/api-explore'; import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format'; import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; import { SITE_URL, routes } from '@/lib/site'; import { topicById } from '@/lib/topics'; import type { CountrySummary, FormatSpec, Series } from '@/lib/types'; import type { FramesResponse } from '@/lib/types-analytics'; import type { IndicatorResponse, RankedValue } from '@/lib/types-explore'; import { RankRace } from '@/components/charts/rank-race'; import { RankedBars, rankedRowFromCountry, type RankedBarRow } from '@/components/charts/ranked-bars'; import { EmptyState, NotBuiltState } from '@/components/data/empty-state'; import type { ProvenancePayload } from '@/components/data/provenance-context'; import { QualityBadges } from '@/components/data/quality-badge'; import { Section } from '@/components/data/section'; import { CodeBlock } from '@/components/explore/copy-button'; import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; import { DistributionPanel } from '@/components/indicators/distribution-panel'; import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare'; import { IndicatorMap } from '@/components/indicators/indicator-map'; import { IndicatorTrend } from '@/components/indicators/indicator-trend'; import { baseFeatures } from '@/components/indicators/map-geometry'; import { RelatedTable } from '@/components/indicators/related-table'; export const revalidate = 900; type Params = { slug: string }; type SP = Record; async function load(slug: string): Promise { try { return await apiExplore.indicator(slug); } catch (e) { if (isNotFound(e)) return null; if (isNotBuilt(e)) return 'not-built'; throw e; } } export async function generateMetadata({ params }: { params: Promise }): Promise { const { slug } = await params; const data = await load(slug); if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } }; const ind = data.indicator; const name = titleCase(ind.short_name ?? ind.name ?? slug); const title = seoTitle.indicator(name); const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' }); const canonical = routes.indicator(ind.slug); return { title: { absolute: `${title} | ${t('site.name')}` }, description, alternates: { canonical }, openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description }, }; } /** "Life expectancy" → "Life Expectancy"; keeps acronyms (GDP, CO₂) and short function words lower-case. */ function titleCase(s: string): string { const small = new Set(['of', 'per', 'to', 'in', 'at', 'by', 'and', 'or', 'the', 'a', 'an', 'vs', 'on', 'for', 'as']); return s .split(' ') .map((w, i) => (i > 0 && small.has(w.toLowerCase()) ? w.toLowerCase() : w === w.toUpperCase() ? w : w.charAt(0).toUpperCase() + w.slice(1))) .join(' '); } function specOf(ind: IndicatorResponse['indicator']): FormatSpec { return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better }; } /** 10-year movers from the frames payload: change between the latest frame and the frame ten years earlier (± 2). */ function decadeMovers(frames: FramesResponse | null, countries: Map, hib: boolean | null | undefined, relative: boolean): { up: RankedBarRow[]; down: RankedBarRow[]; from: number; to: number } | null { if (!frames || frames.years.length < 11) return null; const years = frames.years; const toIdx = years.length - 1; const to = years[toIdx]!; let fromIdx = years.findIndex((y) => y >= to - 10); if (fromIdx < 0 || fromIdx === toIdx) return null; if (years[fromIdx]! > to - 8) fromIdx = Math.max(0, fromIdx - 1); const from = years[fromIdx]!; const rows: Array<{ id: string; delta: number; v0: number; v1: number }> = []; for (const [iso, arr] of Object.entries(frames.values)) { const v1 = arr[toIdx]; const v0 = arr[fromIdx]; if (typeof v1 !== 'number' || typeof v0 !== 'number') continue; const pop = countries.get(iso)?.population_latest ?? 0; if (pop < 1_000_000) continue; const delta = relative ? (v0 !== 0 ? ((v1 - v0) / Math.abs(v0)) * 100 : NaN) : v1 - v0; if (!Number.isFinite(delta)) continue; rows.push({ id: iso, delta, v0, v1 }); } if (rows.length < 10) return null; const sorted = [...rows].sort((a, b) => b.delta - a.delta); const mk = (r: { id: string; delta: number; v0: number; v1: number }, i: number): RankedBarRow => { const c = countries.get(r.id); return { id: r.id, label: c?.name ?? r.id, flag: c?.flag ?? null, href: c?.slug ? routes.country(c.slug) : null, value: r.delta, rank: i + 1 }; }; const up = sorted.slice(0, 6).map(mk); const down = sorted.slice(-6).reverse().map(mk); // Direction semantics: when higher is better, "up" is the improvement list; when lower is better, swap. return { up: hib === false ? down : up, down: hib === false ? up : down, from, to }; } export default async function IndicatorPage({ params, searchParams }: { params: Promise; searchParams: Promise }) { const [{ slug }, sp] = await Promise.all([params, searchParams]); const data = await load(slug); if (data === null) notFound(); if (data === 'not-built') return ; const ind = data.indicator; const name = ind.name ?? ind.slug; const spec = specOf(ind); const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null; const highlight = typeof sp.country === 'string' && /^[a-z0-9-]+$/i.test(sp.country) ? sp.country : null; const [countriesRes, frames, trend, related, quality, distribution, race] = await Promise.all([ safe(api.countries()), safe(apiAnalytics.indicatorFrames(ind.slug)), safe(apiExplore.indicatorTrend(ind.slug, 'world')), safe(apiAnalytics.indicatorRelated(ind.slug, { limit: 10 })), safe(apiAnalytics.indicatorQuality(ind.slug)), safe(apiAnalytics.indicatorDistribution(ind.slug, { highlight })), ind.ranking_eligible !== false ? safe(apiAnalytics.race(ind.slug, { top: 10 })) : Promise.resolve(null), ]); const countries = countriesRes?.items ?? []; const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country'); const byId = new Map(countries.map((c) => [c.id, c])); // Default comparison: the 3 largest economies that have data for this indicator. const lastIdx = frames ? frames.years.length - 1 : -1; const hasValue = (id: string) => (frames && lastIdx >= 0 ? typeof frames.values[id]?.[lastIdx] === 'number' : false); const defaults: CompareCountry[] = [...onlyCountries] .filter((c) => isNum(c.gdp_latest) && hasValue(c.id)) .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0)) .slice(0, 3) .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag })); const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null; const initialSeries: Series[] = bundle?.series ?? []; const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' }; const wl = data.world_latest; const worldPayload: ProvenancePayload = { indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description }, value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: frames?.provenance ?? data.top5[0]?.provenance ?? null } : null, country: null, downloadHref: routes.indicatorDownload(ind.slug), }; const hib = ind.higher_is_better; const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best'); const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst'); const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank)); const relative = ['currency', 'number', 'tonnes', 'kwh'].includes(ind.format ?? ''); const movers = decadeMovers(frames, byId, hib, relative); const deltaSpec: FormatSpec = relative ? { format: 'percent', precision: 0, unit: '%' } : { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision }; const missing: CountrySummary[] = frames && lastIdx >= 0 ? onlyCountries.filter((c) => !hasValue(c.id)).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : []; const topic = topicById(ind.topic ?? ''); const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`; const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A'); const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null; const ld = [ jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, description: ind.description, unit: ind.unit, firstYear: data.years.first, lastYear: data.years.last_actual, nCountries: data.coverage.n_countries, sources: data.sources.map((s) => ({ name: s.source_name, url: s.url, licence: s.licence })), modified: data.meta.built_at }), jsonLd.breadcrumbs([{ name: t('indicators.title'), path: routes.indicators() }, ...(topic ? [{ name: topic.short, path: routes.indicators(topic.id) }] : []), { name: ind.short_name ?? name, path: routes.indicator(ind.slug) }]), ]; return ( <>